今天會來研究看看在這個專案裡使用 Coroutines 時可能遇到的情境,因為可能涉及到的很多內容及架構都是在後面才會提到,所以現在先專注在 Coroutines 即可。
現在有一個情境
一個 filter View,上面有三個按鈕,點擊可分別顯示所有工作事項、未完成的工作事項及已完成的工作事項。
這是十分常見的情境,由於現在還沒有考慮打 API 的狀況,我們就只需要考慮 從 Local DB 讀取資料 的情境就好。
我們在專案裡使用的 local db 是 Room ,但是因為才剛開始所以就先做一個測試用的假資料好了
enum class TaskType {
    All, Activated, Completed
}
class TasksRepository {
    suspend fun getTasksFromRoom(): List<String> {
        return fakeGetTasks()
    }
    suspend fun getActivatedTasksFromRoom(): List<String> {
        return fakeGetActivatedTasks()
    }
    suspend fun getCompletedTasksFromRoom(): List<String> {
        return fakeGetCompletedTasks()
    }
    
    ......
    
    private suspend fun fakeGetTasks(): List<String> {
        // 假裝是讀取資料所消耗的 IO 時間
        delay(1500L)
        return mutableListOf("Task1", "Tasks2", "Tasks3")
    }
    private suspend fun fakeGetActivatedTasks(): List<String> {
        // 假裝是讀取資料所消耗的 IO 時間
        delay(1000L)
        return mutableListOf("Task1")
    }
    private suspend fun fakeGetCompletedTasks(): List<String> {
        // 假裝是讀取資料所消耗的 IO 時間
        delay(500L)
        return mutableListOf("Tasks2", "Tasks3")
    }
}
接著是在 ViewModel 獲得資料:
class TaskViewModel(private val repository: TasksRepository) {
    suspend fun getTasks(type: TaskType = TaskType.All): List<String> {
        return withContext(Dispatchers.IO) {
            when (type) {
                TaskType.Activated -> repository.getActivatedTasksFromRoom()
                TaskType.Completed -> repository.getCompletedTasksFromRoom()
                else -> repository.getTasksFromRoom()
            }
        }
    }
}
最後在 Activity 調用:
class MainActivity : AppCompatActivity() {
    private val repository by lazy { TasksRepository() }
    private val viewModel by lazy { TaskViewModel(repository) }
    val scope = MainScope()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        btnAll.setOnClickListener {
            getTasksList(TaskType.All)
        }
        btnActivated.setOnClickListener {
            getTasksList(TaskType.Activated)
        }
        btnCompleted.setOnClickListener {
            getTasksList(TaskType.Completed)
        }
    }
    private fun getTasksList(type: TaskType) {
        scope.launch {
            val tasks = viewModel.getTasks(type)
            Toast.makeText(
                this@MainActivity, 
                tasks.toString(), 
                Toast.LENGTH_SHORT
            ).show()
        }
    }
    override fun onDestroy() {
        super.onDestroy()
        scope.cancel()
    }
}
如此就完成了一個簡單的 Coroutines 小程式!
接下來我們再來探討一些比較複雜的狀況。
可能會有一種狀況:
User 不小心手滑多點了好幾下,會發生什麼事?
以 Case 1 的情況來說,就會重複請求資料!
這個情境乍看之下似乎不影響效果,但是如果有一種情況:
User 反覆在 All, Activated, Completed 按鈕之間瘋狂點擊
此時 QA 就回報,有時候顯示的資料與點擊的類型不一樣。
這是為什麼呢?理論上顯示的資料應該是用戶最後一次選擇的類型啊?
因為用戶瘋狂點擊按鈕時,同時開啟了多個協程讀取資料,由於協程不保證消費順序,因此可能以任意一個順序結束!
這是一個典型的 concurrency bug,一不小心就會掉入陷阱了。
既然原因是同時讀取資料,所以一次只讓讀取資料做一次的話就能解決這個問題了。
比較簡單的方式就是在讀取資料的時候 禁用按鈕 ,這非常容易做到,而且大部分情況下都可以接受這種做法。
其實有很多方式可以達到這個目的,我想討論的是如果不想要禁用按鈕,是否有其他辦法?這邊我提出兩種做法:
- 取消之前的請求,只接受後來的請求
 - 使用之前的請求,拒絕後來的請求
 
在不細究 spec 的情況下兩者都是可行的,下面再來說說具體的作法:
如果用戶點擊新的按鈕,其實就是在告訴程式他們只想要最新的結果,也意味著可以終止前面的請求,我們可以這樣修改程式:
class TasksRepository {
    var controllCoroutines = ControlledCoroutinesExample<List<String>>()
    suspend fun getTasksFromRoom(): List<String> {
        return controllCoroutines.cancelPreviousThenRun {
            fakeGetTasks()
        }
    }
    suspend fun getActivatedTasksFromRoom(): List<String> {
        return controllCoroutines.cancelPreviousThenRun {
            fakeGetActivatedTasks()
        }
    }
    suspend fun getCompletedTasksFromRoom(): List<String> {
        return controllCoroutines.cancelPreviousThenRun {
            fakeGetCompletedTasks()
        }
    }
    
    ......
    
}
class ControlledCoroutinesExample<T> {
    private var cachedTasks: Deferred<T>? = null
    suspend fun cancelPreviousThenRun(block: suspend () -> T): T {
        // 如果當前有正在執行的 cachedTasks,可以直接取消並改成執行最新的請求
        cachedTasks?.cancelAndJoin()
        return coroutineScope {
            // 建立一個 async 並且 suspend
            val newTask = async {
                block()
            }
            
            // newTask 執行完畢時清除舊的 cachedTasks 任務
            newTask.invokeOnCompletion {
                cachedTasks = null
            }
            // newTask 完成後交給 cachedTasks
            cachedTasks = newTask
            // newTask 恢復狀態並開始執行
            newTask.await()
        }
    }
}
其實就是以最開始的請求為準,這種做法比較適用在需要打 API 的時候,因為這樣可以節省一些網路資源,一樣我們來看看不打算採用禁用按鈕的做法時的方案:
class TasksRepository {
    var controllCoroutines = ControlledCoroutinesExample<List<String>>()
    suspend fun getTasksFromRoom(): List<String> {
        return controllCoroutines.joinPreviousOrRun {
            fakeGetTasks()
        }
    }
    suspend fun getActivatedTasksFromRoom(): List<String> {
        return controllCoroutines.joinPreviousOrRun {
            fakeGetActivatedTasks()
        }
    }
    suspend fun getCompletedTasksFromRoom(): List<String> {
        return controllCoroutines.joinPreviousOrRun {
            fakeGetCompletedTasks()
        }
    }
    
    ......
    
}
class ControlledCoroutinesExample<T> {
    private var cachedTasks: Deferred<T>? = null
    ......
    
    suspend fun joinPreviousOrRun(block: suspend () -> T): T {
        // 如果當前有正在執行的 cachedTasks ,直接返回
        activeTask?.let {
            return it.await()
        }
        // 否則建立一個新的 async
        return coroutineScope {
            val newTask = async {
                block()
            }
            newTask.invokeOnCompletion {
                activeTask = null
            }
         
            activeTask = newTask
            newTask.await()
        }
    }
}
這是一個 pseudo code ,只是想表達大致上的意思。
兩個做法的大致思路都是準備一個 cachedTasks 保存正在執行的工作,每次有新的請求時再來檢查。
Kotlin Coroutines 的介紹大概到這裡,其實還有很多無法帶到,我也只是介紹我在這個專案會使用到的東西,有興趣可以自己到官網看看。